Delayed Shutter Camera

What Does It Do?

This autonomous camera system captures images at three different time delays—30 seconds, 1 hour, and 24 hours—triggered by physical buttons. Each image is automatically downloaded to a computer and displayed on a minimal web gallery that updates in real-time.

Three main components:

  • ESP32-S3 Camera on Custom PCB: Takes scheduled photos and serves them over WiFi
  • Python Download Script: Automatically monitors and downloads new images
  • Web Gallery: Minimal interface displaying all captured images with timestamps

System Capabilities:

  • Up to 10 concurrent scheduled captures
  • 800x600 JPEG images (~30-50KB each)
  • 5-7 second download latency
  • Unlimited storage on computer
  • Public web access via ngrok

Project Evolution

Initial Concept: Camera in a Rock

The original vision was a camera hidden inside a decorative rock that would capture one photo every 24 hours. The goal was to create an inconspicuous desk ornament that secretly documented daily life, with images saved to an SD card for later discovery.

First Pivot: Periscope Mechanism

I wanted the camera to periscope up from the rock to take photos, adding mechanical interest and surprise. However, conceptualizing and building a reliable servo motor mechanism within the space constraints proved too challenging within the project timeline.

Final Design: Three Delayed Shutters

The final design embraces simplicity: three buttons triggering captures at different time delays. This proved more reliable, built on previous weeks' work better, and opened interesting use cases—from time-lapse photography to capturing spontaneous moments after a waiting period.

Power Evolution: From Battery to USB

Initially planned as battery-powered for true portability. However, I encountered persistent soldering issues—wires wouldn't stick to battery terminals. After consulting with Jake, I discovered the cause: generic header pin wires from the shop were enamel-coated, preventing proper solder adhesion. Jake suggested harvesting wires from stepper motors instead.

By this point, I had already redesigned for USB power, which ultimately simplified deployment and removed battery life concerns during extended time-lapse sessions.

Key Lesson: Wire quality matters immensely. Enamel coating isn't always visible, but it completely prevents solder adhesion. Use proper hookup wire or harvest from stepper motors for reliable connections.

What Was Designed

1. Custom PCB Breakout Board

Designed for the Xiao ESP32S3 featuring:

LED Selection Rationale: Orange LED was chosen because it operates comfortably at 3.3V. White LEDs require driver circuits, blue LEDs are extremely bright at 3.3V. Orange provides good visibility without being aggressive.

Resistor Calculation: 1000Ω for orange LED at 3.3V: (3.3V - 2.0V) / 0.001A = 1300Ω. Using 1000Ω provides ~1.3mA for better brightness while staying within LED specs.

PCB Design Journey: 12+ Iterations

Phase 1: Fusion 360 Attempts

Phase 2: KiCad Learning Curve

Phase 3: Mods & Milling Refinement

Component Sizing Lessons:

2. 3D Printed Case

Originally planned to embed in a real rock. When rock milling proved problematic (see rock milling documentation), I pivoted to a rock-shaped case designed with Harrison's help.

Design features:

Issues encountered:

Download Case STL File

Case assembly showing design issues

3. ESP32 Firmware

Arduino C++ implementation featuring:

4. Python Download System

Flask-based server application:

5. Web Gallery Interface

Minimal, library catalog-inspired design:

Bill of Materials

Ideal Scenario (Everything Works First Try)

Component Qty Source Unit Cost Total
Xiao ESP32S3 Sense 1 Seeed Studio / Amazon $13.99 $13.99
FR-1 Copper Board (4"×6") 1 Amazon / Digikey $8.00 $8.00
SMD Push Buttons 3 Digikey / Lab stock $0.15 $0.45
Orange LED (0805) 1 Digikey / Lab stock $0.10 $0.10
1000Ω Resistor (0805) 1 Digikey / Lab stock $0.10 $0.10
Male Pin Headers (40-pin) 1 Amazon / Lab stock $0.50 $0.50
Ideal Total $23.14

Actual Cost (Reality of Prototyping)

Component Qty Why Extra Needed Unit Cost Total
Xiao ESP32S3 Sense 2 Testing + final assembly $13.99 $27.98
FR-1 Copper Board 1 Small boards fit multiple attempts $8.00 $8.00
SMD Push Buttons 3 Final design only $0.15 $0.45
Orange LED (0805) 3 Testing, burned one, final $0.10 $0.30
1000Ω Resistor (0805) 3 Lost one, testing, final $0.10 $0.30
Male Pin Headers 2 Multiple board attempts $0.50 $1.00
Actual Total $38.03

Cost Reality: Actual cost was $14.89 more than ideal due to:

  • Backup Xiao for testing separate from final assembly
  • Extra SMD components for losses and testing
  • Small board size allowed many iterations on one copper sheet

Budget Recommendation: Plan for $35-40 for first PCB project with one revision expected.

Fabrication Process

PCB Manufacturing

Tools & Software

Critical Milling Parameters

Traces: 0.75mm flat endmill
Outline: 1/32" endmill
Export: 1000 DPI monochrome PNG
Tool offset: 4 passes at 50% overlap

Common Problems & Solutions

Problem Cause Solution
Hairy, imprecise cuts Broken/dull endmill Replace endmill, check condition before starting
Board lifting during cut Insufficient adhesion Double-sided tape near all edges, small boards work best
Through-holes won't generate Tool diameter mismatch Adjust from 0.80mm to 0.75mm in mods
Traces too thin Export DPI too low Use 1000 DPI minimum for clean traces

Download Complete Toolpaths (.nc file)

Component Assembly

Soldering Sequence

  1. SMD components first: LED, resistor, push buttons
  2. Pin headers: Align carefully, solder one pin, check alignment, complete
  3. Xiao module: Socket onto headers, solder in place
  4. Testing: Upload test code before camera connection

Soldering Tips

Key Discovery: Tip tinner made soldering dramatically faster and cleaner. Highly recommended for any SMD work. Saves time and reduces cold joints.

Complete Code & Configuration

Part 1: ESP32 Firmware

Arduino IDE Settings (CRITICAL):

  • Board: XIAO_ESP32S3
  • PSRAM: OPI PSRAM (camera won't work without this!)
  • Upload Speed: 921600 (or 115200 if fails)
  • USB CDC On Boot: Enabled

Before uploading: Update WiFi credentials in lines 7-8:

const char* ssid = "YOUR_NETWORK_NAME";
const char* password = "YOUR_PASSWORD";
Click to view complete ESP32 code (350 lines)
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include <time.h>

// WiFi credentials - CHANGE THESE
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

// Button pins
const int BUTTON_1 = D0;  // 30 seconds
const int BUTTON_2 = D1;  // 1 hour
const int BUTTON_3 = D2;  // 24 hours
const int LED_PIN = D9;

WebServer server(80);

struct ImageData {
  uint8_t* buffer;
  size_t length;
  String timestamp;
  String buttonName;
  String delayInfo;
  bool available;
  int imageID;
} latestImage;

int totalImagesCaptured = 0;

struct ScheduledCapture {
  bool active;
  unsigned long captureTime;
  String buttonName;
  String delayName;
};

#define MAX_SCHEDULED 10
ScheduledCapture scheduled[MAX_SCHEDULED];

bool button1LastState = HIGH;
bool button2LastState = HIGH;
bool button3LastState = HIGH;

// Camera pins for Xiao ESP32S3 Sense
#define PWDN_GPIO_NUM     -1
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM     10
#define SIOD_GPIO_NUM     40
#define SIOC_GPIO_NUM     39
#define Y9_GPIO_NUM       48
#define Y8_GPIO_NUM       11
#define Y7_GPIO_NUM       12
#define Y6_GPIO_NUM       14
#define Y5_GPIO_NUM       16
#define Y4_GPIO_NUM       18
#define Y3_GPIO_NUM       17
#define Y2_GPIO_NUM       15
#define VSYNC_GPIO_NUM    38
#define HREF_GPIO_NUM     47
#define PCLK_GPIO_NUM     13

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("\n\n=== ESP32 Delayed Shutter Camera ===");
  
  latestImage.buffer = NULL;
  latestImage.available = false;
  latestImage.imageID = 0;
  
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, HIGH);  // OFF (inverted logic)
  
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  
  pinMode(BUTTON_1, INPUT_PULLUP);
  pinMode(BUTTON_2, INPUT_PULLUP);
  pinMode(BUTTON_3, INPUT_PULLUP);
  
  for(int i = 0; i < MAX_SCHEDULED; i++) {
    scheduled[i].active = false;
  }
  
  Serial.println("Initializing camera...");
  if(!initCamera()) {
    Serial.println("Camera init failed!");
    while(1) { 
      digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      delay(200);
    }
  }
  Serial.println("Camera OK!");
  
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.println("========================================");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  Serial.println("========================================");
  
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  setenv("TZ", "EST5EDT,M3.2.0,M11.1.0", 1);
  tzset();
  
  server.on("/", handleRoot);
  server.on("/status", handleStatus);
  server.on("/latest", handleLatest);
  server.on("/download", handleDownload);
  
  server.begin();
  Serial.println("Ready!\n");
}

void loop() {
  server.handleClient();
  
  for(int i = 0; i < MAX_SCHEDULED; i++) {
    if(scheduled[i].active && millis() >= scheduled[i].captureTime) {
      digitalWrite(LED_PIN, HIGH);
      captureImage(scheduled[i].buttonName, scheduled[i].delayName);
      delay(1000);
      digitalWrite(LED_PIN, LOW);
      scheduled[i].active = false;
    }
  }
  
  bool button1State = digitalRead(BUTTON_1);
  bool button2State = digitalRead(BUTTON_2);
  bool button3State = digitalRead(BUTTON_3);
  
  if(button1LastState == HIGH && button1State == LOW) {
    scheduleCapture(30000, "Button1_30sec", "30 seconds");
  }
  button1LastState = button1State;
  
  if(button2LastState == HIGH && button2State == LOW) {
    scheduleCapture(3600000, "Button2_1hr", "1 hour");
  }
  button2LastState = button2State;
  
  if(button3LastState == HIGH && button3State == LOW) {
    scheduleCapture(86400000, "Button3_24hr", "24 hours");
  }
  button3LastState = button3State;
  
  delay(50);
}

void scheduleCapture(unsigned long delayMs, String buttonName, String delayName) {
  int slot = -1;
  for(int i = 0; i < MAX_SCHEDULED; i++) {
    if(!scheduled[i].active) {
      slot = i;
      break;
    }
  }
  
  if(slot == -1) {
    Serial.println("Too many scheduled!");
    for(int i = 0; i < 3; i++) {
      digitalWrite(LED_PIN, HIGH);
      delay(100);
      digitalWrite(LED_PIN, LOW);
      delay(100);
    }
    return;
  }
  
  scheduled[slot].active = true;
  scheduled[slot].captureTime = millis() + delayMs;
  scheduled[slot].buttonName = buttonName;
  scheduled[slot].delayName = delayName;
  
  digitalWrite(LED_PIN, HIGH);
  Serial.println("\n>>> " + buttonName + " SCHEDULED <<<");
  delay(300);
  digitalWrite(LED_PIN, LOW);
}

bool initCamera() {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_SVGA;
  config.jpeg_quality = 10;
  config.fb_count = 1;
  
  return (esp_camera_init(&config) == ESP_OK);
}

void captureImage(String buttonName, String delayInfo) {
  camera_fb_t * fb = esp_camera_fb_get();
  if(!fb) return;
  
  if(latestImage.buffer != NULL) free(latestImage.buffer);
  
  latestImage.buffer = (uint8_t*)malloc(fb->len);
  if(latestImage.buffer != NULL) {
    memcpy(latestImage.buffer, fb->buf, fb->len);
    latestImage.length = fb->len;
    latestImage.timestamp = getTimestamp();
    latestImage.buttonName = buttonName;
    latestImage.delayInfo = delayInfo;
    latestImage.available = true;
    latestImage.imageID++;
    totalImagesCaptured++;
  }
  
  esp_camera_fb_return(fb);
}

String getTimestamp() {
  struct tm timeinfo;
  if(!getLocalTime(&timeinfo)) return String(millis());
  char buffer[80];
  strftime(buffer, sizeof(buffer), "%Y-%m-%d_%H-%M-%S", &timeinfo);
  return String(buffer);
}

void handleRoot() {
  String html = "<html><body><h1>ESP32 Camera</h1>";
  html += "<p>Images captured: " + String(totalImagesCaptured) + "</p>";
  html += "<p><a href='/status'>Status</a> | <a href='/download'>Download</a></p>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

void handleStatus() {
  String json = "{\"available\":" + String(latestImage.available ? "true" : "false");
  json += ",\"imageID\":" + String(latestImage.imageID);
  json += ",\"totalCaptured\":" + String(totalImagesCaptured);
  json += ",\"timestamp\":\"" + latestImage.timestamp + "\"";
  json += ",\"buttonName\":\"" + latestImage.buttonName + "\"";
  json += ",\"delayInfo\":\"" + latestImage.delayInfo + "\"}";
  server.send(200, "application/json", json);
}

void handleLatest() {
  if(latestImage.available && latestImage.buffer != NULL) {
    server.send_P(200, "image/jpeg", (const char*)latestImage.buffer, latestImage.length);
  } else {
    server.send(404, "text/plain", "No image");
  }
}

void handleDownload() {
  if(latestImage.available && latestImage.buffer != NULL) {
    String filename = latestImage.buttonName + "_" + latestImage.timestamp + ".jpg";
    server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
    server.send_P(200, "image/jpeg", (const char*)latestImage.buffer, latestImage.length);
  } else {
    server.send(404, "text/plain", "No image");
  }
}

Upload Instructions

  1. Connect Xiao to computer via USB-C
  2. Open Arduino IDE, paste code, update WiFi credentials
  3. Verify PSRAM setting: Tools → PSRAM → "OPI PSRAM"
  4. Click Upload
  5. If upload fails: Hold BOOT, click Upload, release BOOT when dots appear
  6. After upload: Press RESET button
  7. Open Serial Monitor (115200 baud) to see IP address

Part 2: Python Download Script

Save as camera_downloader.py

Installation:

pip3 install --break-system-packages flask requests

Configuration: Update these three lines:

ESP32_IP = "192.168.72.72"  # Your ESP32's IP from Serial Monitor
DOWNLOAD_FOLDER = "/path/to/your/images"  # Where to save images
WEB_PORT = 8765  # Change if port conflicts
Click to view complete Python script (200 lines)
#!/usr/bin/env python3
import requests
import os
import time
from datetime import datetime
from flask import Flask, render_template_string, send_from_directory
import threading

ESP32_IP = "192.168.72.72"
DOWNLOAD_FOLDER = "/path/to/your/images"
CHECK_INTERVAL = 5
WEB_PORT = 8765

os.makedirs(DOWNLOAD_FOLDER, exist_ok=True)
last_image_id = 0
app = Flask(__name__)

GALLERY_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
    <title>Camera Feed</title>
    <meta http-equiv="refresh" content="10">
    <style>
        body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #fff; }
        table { border-collapse: separate; border-spacing: 20px; width: 100%; table-layout: fixed; }
        td { padding: 0; width: 33.33%; }
        img { width: 100%; height: auto; display: block; }
        .info { font-size: 14px; color: #666; padding: 10px 0; text-align: center; }
        .delay { color: #999; font-size: 12px; }
    </style>
</head>
<body>
    {% if images %}
        <table>
            {% for row in images | batch(3) %}
            <tr>
                {% for image in row %}
                <td>
                    <img src="/images/{{ image.filename }}" alt="Capture">
                    <div class="info">
                        {{ image.timestamp }}<br>
                        <span class="delay">{{ image.delay }}</span>
                    </div>
                </td>
                {% endfor %}
            </tr>
            {% endfor %}
        </table>
    {% else %}
        <p style="color: #666; text-align: center;">No images yet.</p>
    {% endif %}
</body>
</html>
"""

def download_new_image():
    global last_image_id
    try:
        response = requests.get(f"http://{ESP32_IP}/status", timeout=5)
        if response.status_code != 200:
            return False
        
        data = response.json()
        if not data['available']:
            return False
        
        current_id = data['imageID']
        if current_id <= last_image_id:
            return False
        
        print(f"\n>>> New image detected! ID: {current_id}")
        img_response = requests.get(f"http://{ESP32_IP}/latest", timeout=10)
        
        if img_response.status_code == 200:
            filename = f"{data['buttonName']}_{data['timestamp']}.jpg"
            filepath = os.path.join(DOWNLOAD_FOLDER, filename)
            
            with open(filepath, 'wb') as f:
                f.write(img_response.content)
            
            print(f"✓ Downloaded: {filename}")
            last_image_id = current_id
            return True
        
    except Exception as e:
        print(f"Error: {e}")
        return False

def monitor_loop():
    print(f"\n=== Monitoring ESP32 at {ESP32_IP} ===")
    while True:
        download_new_image()
        time.sleep(CHECK_INTERVAL)

@app.route('/')
def gallery():
    images = []
    if os.path.exists(DOWNLOAD_FOLDER):
        for filename in sorted(os.listdir(DOWNLOAD_FOLDER), reverse=True):
            if filename.endswith('.jpg'):
                parts = filename.replace('.jpg', '').split('_')
                button = parts[0] if len(parts) > 0 else "Unknown"
                delay = parts[1] if len(parts) > 1 else "Unknown"
                timestamp = "Unknown"
                if len(parts) >= 4:
                    timestamp = f"{parts[2]} {parts[3].replace('-', ':')}"
                
                images.append({
                    'filename': filename,
                    'button': button,
                    'delay': delay,
                    'timestamp': timestamp
                })
    
    return render_template_string(GALLERY_TEMPLATE, images=images)

@app.route('/images/<filename>')
def serve_image(filename):
    return send_from_directory(DOWNLOAD_FOLDER, filename)

if __name__ == '__main__':
    print("=" * 50)
    print("ESP32 Camera Downloader & Gallery")
    print("=" * 50)
    
    monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
    monitor_thread.start()
    
    print(f"\n>>> Open http://localhost:{WEB_PORT} in browser <<<\n")
    app.run(host='0.0.0.0', port=WEB_PORT, debug=False)

Running the System

# Navigate to project folder
cd ~/path/to/project

# Run script (keeps terminal open to see output)
python3 camera_downloader.py

# OR run in background
nohup python3 camera_downloader.py > camera_log.txt 2>&1 &

# Keep Mac awake (required for downloads)
caffeinate -d

Make it Public with ngrok

# Install ngrok
brew install ngrok

# Sign up at ngrok.com, then authenticate
ngrok authtoken YOUR_TOKEN

# Start tunnel (Python script must be running first!)
ngrok http 8765

# Share the https:// URL it provides!

Testing & Results

Functionality Validation

Test Expected Actual Status
Button 1 (30 sec) Image at 30s 30.2s ✓ Pass
Button 2 (1 hour) Image at 1hr 59m 58s ✓ Pass
Button 3 (24 hours) Image at 24hr 23h 59m 45s ✓ Pass
Multiple concurrent All execute All 10 slots work ✓ Pass
LED feedback Blink on press 300ms consistent ✓ Pass
Auto-download Within 10s 5-7s average ✓ Pass

Performance Metrics

What Worked Well

What Didn't Work

Implications & Future

Photography Reimagined

This project reintroduces whimsy and spontaneity into digital photography by fundamentally changing our relationship with capture. Unlike traditional cameras where we consciously compose and perform for the lens, the delayed shutter creates a temporal gap between intention and documentation—we press a button and then forget, living naturally until the camera quietly observes us 30 seconds, an hour, or a day later.

This transforms photography from a performative act into ambient documentation, capturing moments as they actually unfold rather than how we wish to present them. The delays force us to surrender control, embracing imperfection and authenticity over curated perfection.

In an era dominated by endless selfies and carefully staged social media content, this camera asks: what if documentation was playful, unpredictable, and honest? What if we could see ourselves and our spaces as they truly are, not as we want them to appear? The result is a photography practice rooted in curiosity and chance rather than vanity and control—a small act of rebellion against the tyranny of the perfect shot.

Future Improvements

Most Important Lesson: This project taught that iteration is the core of hardware development. Every "failure" was actually a step toward understanding—of tools, materials, processes, and design constraints. The final working device represents not just technical achievement but accumulated knowledge from dozens of mistakes.

References & Resources

Technical Documentation

Assistance & Collaboration